CrewWidgetTab.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. 'use client';
  2. import { useState, useEffect, useCallback } from 'react';
  3. import { fetchApi } from '@/lib/utils/client';
  4. import { useStudioContext } from '@/app/studio/context';
  5. import type { CrewWidgetConfigResponse, CrewWidgetConfigItem } from '@/types/response/crew/widgetConfig';
  6. import { CREW_PERIODS, CREW_WIDGET_THEMES } from '../../widget/constants';
  7. import { type FormState, createEmptyForm, formatDateTime } from '../../widget/types';
  8. import CrewWidgetFormPanel from '../../widget/_components/CrewWidgetFormPanel';
  9. import CrewWidgetPreviewPanel from '../../widget/_components/CrewWidgetPreviewPanel';
  10. import { Button } from '@/components/ui/button';
  11. import { Checkbox } from '@/components/ui/checkbox';
  12. import { Separator } from '@/components/ui/separator';
  13. type Props = { crewID: number };
  14. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  15. export default function CrewWidgetTab({ crewID: _crewID }: Props)
  16. {
  17. const { channelID } = useStudioContext();
  18. const [items, setItems] = useState<CrewWidgetConfigItem[]>([]);
  19. const [loading, setLoading] = useState(true);
  20. const [saving, setSaving] = useState(false);
  21. const [editItem, setEditItem] = useState<CrewWidgetConfigItem|null>(null);
  22. const [showForm, setShowForm] = useState(false);
  23. const [selected, setSelected] = useState<Set<number>>(new Set());
  24. const [togglingIDs, setTogglingIDs] = useState<Set<number>>(new Set());
  25. const [form, setForm] = useState<FormState>(createEmptyForm());
  26. // ── 활성/비활성 토글 ─────────────────────────────
  27. const handleToggleActive = async (item: CrewWidgetConfigItem) => {
  28. if (!channelID || togglingIDs.has(item.id)) {
  29. return;
  30. }
  31. const next = !item.isActive;
  32. setTogglingIDs(prev => {
  33. const s = new Set(prev);
  34. s.add(item.id);
  35. return s;
  36. });
  37. setItems(prev => prev.map(x => x.id === item.id ? { ...x, isActive: next } : x));
  38. try {
  39. const res = await fetchApi<{ id: number; isActive: boolean }>(
  40. `/api/studio/crew/widget/config/${item.id}/active`,
  41. {
  42. method: 'PATCH',
  43. body: { channelID, isActive: next },
  44. silent: true
  45. }
  46. );
  47. if (!res.success) {
  48. setItems(prev => prev.map(x => x.id === item.id ? { ...x, isActive: item.isActive } : x));
  49. alert(res.message || '활성 상태 변경에 실패했습니다.');
  50. } else if (res.data && res.data.isActive !== next) {
  51. setItems(prev => prev.map(x => x.id === item.id ? { ...x, isActive: res.data!.isActive } : x));
  52. }
  53. } catch (err) {
  54. setItems(prev => prev.map(x => x.id === item.id ? { ...x, isActive: item.isActive } : x));
  55. alert(err instanceof Error ? err.message : '활성 상태 변경에 실패했습니다.');
  56. } finally {
  57. setTogglingIDs(prev => {
  58. const s = new Set(prev);
  59. s.delete(item.id);
  60. return s;
  61. });
  62. }
  63. };
  64. const fetchList = useCallback(() => {
  65. if (!channelID) {
  66. setLoading(false);
  67. return;
  68. }
  69. setLoading(true);
  70. fetchApi<CrewWidgetConfigResponse>(`/api/studio/crew/widget/config/${channelID}`)
  71. .then(res => setItems(res.data?.list ?? []))
  72. .catch(() => {})
  73. .finally(() => setLoading(false));
  74. }, [channelID]);
  75. useEffect(() => {
  76. fetchList();
  77. }, [fetchList]);
  78. const getPeriodLabel = (p: number) => CREW_PERIODS.find(x => x.value === p)?.label ?? '-';
  79. const getThemeLabel = (t: number) => CREW_WIDGET_THEMES.find(x => x.value === t)?.label ?? '-';
  80. const openAdd = () => {
  81. setEditItem(null);
  82. setForm(createEmptyForm());
  83. setShowForm(true);
  84. };
  85. const openEdit = (item: CrewWidgetConfigItem) => {
  86. setEditItem(item);
  87. setShowForm(true);
  88. };
  89. const handleSaved = () => {
  90. setShowForm(false);
  91. setEditItem(null);
  92. fetchList();
  93. };
  94. const toggleSelect = (id: number) => {
  95. setSelected(prev => {
  96. const next = new Set(prev);
  97. if (next.has(id)) {
  98. next.delete(id);
  99. } else {
  100. next.add(id);
  101. }
  102. return next;
  103. });
  104. };
  105. const handleBatchDelete = async () => {
  106. if (selected.size === 0) {
  107. return;
  108. }
  109. if (!confirm(`${selected.size}개 설정을 삭제하시겠습니까?`)) {
  110. return;
  111. }
  112. for (const id of selected) {
  113. try {
  114. await fetchApi(`/api/studio/crew/widget/config/${id}/${channelID}`, { method: 'DELETE' });
  115. } catch {
  116. }
  117. }
  118. setSelected(new Set());
  119. fetchList();
  120. };
  121. if (loading) {
  122. return <p className="studio-page__empty">준비 중...</p>;
  123. }
  124. if (showForm) {
  125. return (
  126. <>
  127. <div className="crew-members__toolbar">
  128. <h2 className="crew-members__subtitle">{editItem ? '위젯 수정' : '위젯 추가'}</h2>
  129. <Button variant="outline" size="sm" type="button" onClick={() => setShowForm(false)}>< 뒤로가기</Button>
  130. </div>
  131. <div className="pt-3 pb-3">
  132. <Separator orientation="horizontal" />
  133. </div>
  134. <div className="crew-widget-layout">
  135. <CrewWidgetPreviewPanel form={form} />
  136. <Separator orientation="vertical" />
  137. <CrewWidgetFormPanel
  138. editItem={editItem ?? undefined}
  139. form={form}
  140. onFormChange={setForm}
  141. onSaved={handleSaved}
  142. onCancel={() => setShowForm(false)}
  143. channelID={channelID ?? undefined}
  144. saving={saving}
  145. setSaving={setSaving}
  146. />
  147. </div>
  148. </>
  149. );
  150. }
  151. return (
  152. <div className="crew-widget">
  153. <div className="crew-members__toolbar pb-3">
  154. <h2 className="crew-members__subtitle">위젯 설정 ({items.length}개)</h2>
  155. <div className="studio-page__actions">
  156. {selected.size > 0 && (
  157. <Button variant="destructive" size="sm" type="button" onClick={handleBatchDelete}>{selected.size}개 삭제</Button>
  158. )}
  159. <Button size="sm" type="button" onClick={openAdd}>+ 추가</Button>
  160. </div>
  161. </div>
  162. <div className="studio-page__table-wrap">
  163. <table className="studio-page__table">
  164. <thead>
  165. <tr>
  166. <th><Checkbox checked={items.length > 0 && selected.size === items.length} onCheckedChange={() => setSelected(items.length === selected.size ? new Set() : new Set(items.map(i => i.id)))} /></th>
  167. <th>제목</th>
  168. <th>기간</th>
  169. <th>테마</th>
  170. <th>최대 표시</th>
  171. <th>활성</th>
  172. <th>작업</th>
  173. </tr>
  174. </thead>
  175. <tbody>
  176. {items.length === 0 ? (
  177. <tr><td colSpan={7} className="studio-page__empty">등록된 위젯 설정이 없습니다.</td></tr>
  178. ) : items.map(item => (
  179. <tr key={item.id}>
  180. <td><Checkbox checked={selected.has(item.id)} onCheckedChange={() => toggleSelect(item.id)} /></td>
  181. <td>{item.title}</td>
  182. <td>
  183. {getPeriodLabel(item.period)}
  184. {item.period === 5 && item.startAt && item.endAt && (
  185. <div className="text-xs text-muted-foreground">{formatDateTime(item.startAt)} ~ {formatDateTime(item.endAt)}</div>
  186. )}
  187. </td>
  188. <td>{getThemeLabel(item.theme)}</td>
  189. <td>{item.maxDisplayCount}명</td>
  190. <td>
  191. <button
  192. type="button"
  193. role="switch"
  194. aria-checked={item.isActive}
  195. aria-busy={togglingIDs.has(item.id)}
  196. aria-label={`${item.title || `#${item.id}`} 활성 상태 ${item.isActive ? '끄기' : '켜기'}`}
  197. className={`crew-widget__active-toggle${item.isActive ? ' crew-widget__active-toggle--on' : ''}${togglingIDs.has(item.id) ? ' crew-widget__active-toggle--busy' : ''}`}
  198. onClick={() => handleToggleActive(item)}
  199. disabled={togglingIDs.has(item.id) || saving}
  200. title={item.isActive ? '비활성화' : '활성화'}
  201. >
  202. <span className="crew-widget__active-toggle-knob" aria-hidden="true" />
  203. </button>
  204. </td>
  205. <td><Button variant="outline" size="sm" type="button" onClick={() => openEdit(item)}>수정</Button></td>
  206. </tr>
  207. ))}
  208. </tbody>
  209. </table>
  210. </div>
  211. </div>
  212. );
  213. }